/**
 * NOTE: This file is a legacy version of the summary generation code, and is kept around for
 * backwards compatibility, until we decide to remove the support for the old summary format.
 *
 * This file contains code used to generate a textual summary of tests results, as displayed
 * in the user's terminal at the end of a k6 test run, also known as "end of test summary".
 *
 * The main entry point is the `generateTextSummary` function, which takes the test data,
 * and returns a formatted string summarizing the test results, ready to be written to the terminal.
 *
 * For convenience, the file also exports the `humanizeValue` function.
 */
exports.humanizeValue = humanizeValue
exports.textSummary = generateTextSummary

var forEach = function (obj, callback) {
	for (var key in obj) {
		if (obj.hasOwnProperty(key)) {
			if (callback(key, obj[key])) {
				break
			}
		}
	}
}

var palette = {
	bold: 1,
	faint: 2,
	red: 31,
	green: 32,
	cyan: 36,
	//TODO: add others?
}

var groupPrefix = '█'
var detailsPrefix = '↳'
var succMark = '✓'
var failMark = '✗'
var defaultOptions = {
	indent: ' ',
	enableColors: true,
	summaryTimeUnit: null,
	summaryTrendStats: null,
}

// strWidth tries to return the actual width the string will take up on the
// screen, without any terminal formatting, unicode ligatures, etc.
function strWidth(s) {
	// TODO: determine if NFC or NFKD are not more appropriate? or just give up? https://hsivonen.fi/string-length/
	var data = s.normalize('NFKC') // This used to be NFKD in Go, but this should be better
	var inEscSeq = false
	var inLongEscSeq = false
	var width = 0
	for (var char of data) {
		if (char.done) {
			break
		}

		// Skip over ANSI escape codes.
		if (char == '\x1b') {
			inEscSeq = true
			continue
		}
		if (inEscSeq && char == '[') {
			inLongEscSeq = true
			continue
		}
		if (inEscSeq && inLongEscSeq && char.charCodeAt(0) >= 0x40 && char.charCodeAt(0) <= 0x7e) {
			inEscSeq = false
			inLongEscSeq = false
			continue
		}
		if (inEscSeq && !inLongEscSeq && char.charCodeAt(0) >= 0x40 && char.charCodeAt(0) <= 0x5f) {
			inEscSeq = false
			continue
		}

		if (!inEscSeq && !inLongEscSeq) {
			width++
		}
	}
	return width
}

function summarizeCheck(indent, check, decorate) {
	if (check.fails == 0) {
		return decorate(indent + succMark + ' ' + check.name, palette.green)
	}

	var succPercent = Math.floor((100 * check.passes) / (check.passes + check.fails))
	return decorate(
		indent +
		failMark +
		' ' +
		check.name +
		'\n' +
		indent +
		' ' +
		detailsPrefix +
		'  ' +
		succPercent +
		'% — ' +
		succMark +
		' ' +
		check.passes +
		' / ' +
		failMark +
		' ' +
		check.fails,
		palette.red
	)
}

function summarizeGroup(indent, group, decorate) {
	var result = []
	if (group.name != '') {
		result.push(indent + groupPrefix + ' ' + group.name + '\n')
		indent = indent + '  '
	}

	for (var i = 0; i < group.checks.length; i++) {
		result.push(summarizeCheck(indent, group.checks[i], decorate))
	}
	if (group.checks.length > 0) {
		result.push('')
	}
	for (var i = 0; i < group.groups.length; i++) {
		Array.prototype.push.apply(result, summarizeGroup(indent, group.groups[i], decorate))
	}

	return result
}

function displayNameForMetric(name) {
	var subMetricPos = name.indexOf('{')
	if (subMetricPos >= 0) {
		return '{ ' + name.substring(subMetricPos + 1, name.length - 1) + ' }'
	}
	return name
}

function indentForMetric(name) {
	if (name.indexOf('{') >= 0) {
		return '  '
	}
	return ''
}

function humanizeBytes(bytes) {
	var units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
	var base = 1000
	if (bytes < 10) {
		return bytes + ' B'
	}

	var e = Math.floor(Math.log(bytes) / Math.log(base))
	var suffix = units[e | 0]
	var val = Math.floor((bytes / Math.pow(base, e)) * 10 + 0.5) / 10
	return val.toFixed(val < 10 ? 1 : 0) + ' ' + suffix
}

var unitMap = {
	s: { unit: 's', coef: 0.001 },
	ms: { unit: 'ms', coef: 1 },
	us: { unit: 'µs', coef: 1000 },
}

function toFixedNoTrailingZeros(val, prec) {
	// TODO: figure out something better?
	return parseFloat(val.toFixed(prec)).toString()
}

function toFixedNoTrailingZerosTrunc(val, prec) {
	var mult = Math.pow(10, prec)
	return toFixedNoTrailingZeros(Math.trunc(mult * val) / mult, prec)
}

function humanizeGenericDuration(dur) {
	if (dur === 0) {
		return '0s'
	}

	if (dur < 0.001) {
		// smaller than a microsecond, print nanoseconds
		return Math.trunc(dur * 1000000) + 'ns'
	}
	if (dur < 1) {
		// smaller than a millisecond, print microseconds
		return toFixedNoTrailingZerosTrunc(dur * 1000, 2) + 'µs'
	}
	if (dur < 1000) {
		// duration is smaller than a second
		return toFixedNoTrailingZerosTrunc(dur, 2) + 'ms'
	}

	var result = toFixedNoTrailingZerosTrunc((dur % 60000) / 1000, dur > 60000 ? 0 : 2) + 's'
	var rem = Math.trunc(dur / 60000)
	if (rem < 1) {
		// less than a minute
		return result
	}
	result = (rem % 60) + 'm' + result
	rem = Math.trunc(rem / 60)
	if (rem < 1) {
		// less than an hour
		return result
	}
	return rem + 'h' + result
}

function humanizeDuration(dur, timeUnit) {
	if (timeUnit !== '' && unitMap.hasOwnProperty(timeUnit)) {
		return (dur * unitMap[timeUnit].coef).toFixed(2) + unitMap[timeUnit].unit
	}

	return humanizeGenericDuration(dur)
}

function humanizeValue(val, metric, timeUnit) {
	if (metric.type == 'rate') {
		// Truncate instead of round when decreasing precision to 2 decimal places
		return (Math.trunc(val * 100 * 100) / 100).toFixed(2) + '%'
	}

	switch (metric.contains) {
		case 'data':
			return humanizeBytes(val)
		case 'time':
			return humanizeDuration(val, timeUnit)
		default:
			return toFixedNoTrailingZeros(val, 6)
	}
}

function nonTrendMetricValueForSum(metric, timeUnit) {
	switch (metric.type) {
		case 'counter':
			return [
				humanizeValue(metric.values.count, metric, timeUnit),
				humanizeValue(metric.values.rate, metric, timeUnit) + '/s',
			]
		case 'gauge':
			return [
				humanizeValue(metric.values.value, metric, timeUnit),
				'min=' + humanizeValue(metric.values.min, metric, timeUnit),
				'max=' + humanizeValue(metric.values.max, metric, timeUnit),
			]
		case 'rate':
			return [
				humanizeValue(metric.values.rate, metric, timeUnit),
				`${metric.values.passes} out of ${metric.values.passes + metric.values.fails}`,
			]
		default:
			return ['[no data]']
	}
}

function summarizeMetrics(options, data, decorate) {
	var indent = options.indent + '  '
	var result = []

	var names = []
	var nameLenMax = 0

	var nonTrendValues = {}
	var nonTrendValueMaxLen = 0
	var nonTrendExtras = {}
	var nonTrendExtraMaxLens = [0, 0]

	var trendCols = {}
	var numTrendColumns = options.summaryTrendStats.length
	var trendColMaxLens = new Array(numTrendColumns).fill(0)
	forEach(data.metrics, function (name, metric) {
		names.push(name)
		// When calculating widths for metrics, account for the indentation on submetrics.
		var displayName = indentForMetric(name) + displayNameForMetric(name)
		var displayNameWidth = strWidth(displayName)
		if (displayNameWidth > nameLenMax) {
			nameLenMax = displayNameWidth
		}

		if (metric.type == 'trend') {
			var cols = []
			for (var i = 0; i < numTrendColumns; i++) {
				var tc = options.summaryTrendStats[i]
				var value = metric.values[tc]
				if (tc === 'count') {
					value = value.toString()
				} else {
					value = humanizeValue(value, metric, options.summaryTimeUnit)
				}
				var valLen = strWidth(value)
				if (valLen > trendColMaxLens[i]) {
					trendColMaxLens[i] = valLen
				}
				cols[i] = value
			}
			trendCols[name] = cols
			return
		}
		var values = nonTrendMetricValueForSum(metric, options.summaryTimeUnit)
		nonTrendValues[name] = values[0]
		var valueLen = strWidth(values[0])
		if (valueLen > nonTrendValueMaxLen) {
			nonTrendValueMaxLen = valueLen
		}
		nonTrendExtras[name] = values.slice(1)
		for (var i = 1; i < values.length; i++) {
			var extraLen = strWidth(values[i])
			if (extraLen > nonTrendExtraMaxLens[i - 1]) {
				nonTrendExtraMaxLens[i - 1] = extraLen
			}
		}
	})

	// sort all metrics but keep sub metrics grouped with their parent metrics
	names.sort(function (metric1, metric2) {
		var parent1 = metric1.split('{', 1)[0]
		var parent2 = metric2.split('{', 1)[0]
		var result = parent1.localeCompare(parent2)
		if (result !== 0) {
			return result
		}
		var sub1 = metric1.substring(parent1.length)
		var sub2 = metric2.substring(parent2.length)
		return sub1.localeCompare(sub2)
	})

	var getData = function (name) {
		if (trendCols.hasOwnProperty(name)) {
			var cols = trendCols[name]
			var tmpCols = new Array(numTrendColumns)
			for (var i = 0; i < cols.length; i++) {
				tmpCols[i] =
					options.summaryTrendStats[i] +
					'=' +
					decorate(cols[i], palette.cyan) +
					' '.repeat(trendColMaxLens[i] - strWidth(cols[i]))
			}
			return tmpCols.join(' ')
		}

		var value = nonTrendValues[name]
		var fmtData = decorate(value, palette.cyan) + ' '.repeat(nonTrendValueMaxLen - strWidth(value))

		var extras = nonTrendExtras[name]
		if (extras.length == 1) {
			fmtData = fmtData + ' ' + decorate(extras[0], palette.cyan, palette.faint)
		} else if (extras.length > 1) {
			var parts = new Array(extras.length)
			for (var i = 0; i < extras.length; i++) {
				parts[i] =
					decorate(extras[i], palette.cyan, palette.faint) +
					' '.repeat(nonTrendExtraMaxLens[i] - strWidth(extras[i]))
			}
			fmtData = fmtData + ' ' + parts.join(' ')
		}

		return fmtData
	}

	for (var name of names) {
		var metric = data.metrics[name]
		var mark = ' '
		var markColor = function (text) {
			return text
		} // noop

		if (metric.thresholds) {
			mark = succMark
			markColor = function (text) {
				return decorate(text, palette.green)
			}
			forEach(metric.thresholds, function (name, threshold) {
				if (!threshold.ok) {
					mark = failMark
					markColor = function (text) {
						return decorate(text, palette.red)
					}
					return true // break
				}
			})
		}
		var fmtIndent = indentForMetric(name)
		var fmtName = displayNameForMetric(name)
		fmtName =
			fmtName +
			decorate(
				'.'.repeat(nameLenMax - strWidth(fmtName) - strWidth(fmtIndent) + 3) + ':',
				palette.faint
			)

		result.push(indent + fmtIndent + markColor(mark) + ' ' + fmtName + ' ' + getData(name))
	}

	return result
}

function generateTextSummary(data, options) {
	var mergedOpts = Object.assign({}, defaultOptions, data.options, options)
	var lines = []

	// TODO: move all of these functions into an object with methods?
	var decorate = function (text) {
		return text
	}
	if (mergedOpts.enableColors) {
		decorate = function (text, color /*, ...rest*/) {
			var result = '\x1b[' + color
			for (var i = 2; i < arguments.length; i++) {
				result += ';' + arguments[i]
			}
			return result + 'm' + text + '\x1b[0m'
		}
	}

	Array.prototype.push.apply(
		lines,
		summarizeGroup(mergedOpts.indent + '    ', data.root_group, decorate)
	)

	Array.prototype.push.apply(lines, summarizeMetrics(mergedOpts, data, decorate))

	return lines.join('\n')
}

